package play.classloading.enhancers;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;

import javassist.CannotCompileException;
import javassist.CtBehavior;
import javassist.CtClass;
import javassist.CtConstructor;
import javassist.CtField;
import javassist.CtMethod;
import javassist.CtNewConstructor;
import javassist.NotFoundException;
import javassist.expr.ExprEditor;
import javassist.expr.FieldAccess;
import play.Logger;
import play.Play;
import play.classloading.ApplicationClasses.ApplicationClass;
import play.exceptions.UnexpectedException;

/**
 * Generate valid JavaBeans.
 */
public class PropertiesEnhancer extends Enhancer {

    private boolean enabled = Boolean.parseBoolean(Play.configuration.getProperty("play.propertiesEnhancer.enabled", "true"));
    private boolean generateAccessors = Boolean.parseBoolean(Play.configuration.getProperty("play.propertiesEnhancer.generateAccessors", "true"));

    @Override
    public void enhanceThisClass(ApplicationClass applicationClass) throws Exception {

        if (!enabled) return;

        CtClass ctClass = makeClass(applicationClass);
        if (ctClass.isInterface() || ctClass.getName().endsWith(".package")) {
            return;
        }

        addDefaultConstructor(ctClass);

        if (generateAccessors && !isScala(applicationClass)) { // Temporary hack for Scala: skip generating getters/setters
            generateAccessors(ctClass);
            addDefaultConstructor2(ctClass);
            interceptAllFieldsAccess(ctClass);
        }

        applicationClass.enhancedByteCode = ctClass.toBytecode();
        ctClass.defrost();
    }

    private void addDefaultConstructor(CtClass ctClass) {
        try {
            boolean hasDefaultConstructor = hasDefaultConstructor(ctClass);
            if (!hasDefaultConstructor && !ctClass.isInterface()) {
                CtConstructor defaultConstructor = CtNewConstructor.make("public " + ctClass.getSimpleName() + "() {}", ctClass);
                ctClass.addConstructor(defaultConstructor);
            }
        } catch (Exception e) {
            Logger.error(e, "Failed to generate default constructor for " + ctClass.getName());
            throw new UnexpectedException("Failed to generate default constructor for " + ctClass.getName(), e);
        }
    }

    private void addDefaultConstructor2(CtClass ctClass) {
        try {
            if (!hasDefaultConstructor(ctClass)) {
                CtConstructor defaultConstructor = CtNewConstructor.defaultConstructor(ctClass);
                ctClass.addConstructor(defaultConstructor);
            }
        } catch (Exception e) {
            Logger.error(e, "Failed to generate default constructor for " + ctClass.getName());
            throw new UnexpectedException("Failed to generate default constructor for " + ctClass.getName(), e);
        }
    }

    private boolean hasDefaultConstructor(CtClass ctClass) throws NotFoundException {
        for (CtConstructor constructor : ctClass.getDeclaredConstructors()) {
            if (constructor.getParameterTypes().length == 0) {
                return true;
            }
        }
        return false;
    }

    private void generateAccessors(CtClass ctClass) {
        for (CtField ctField : ctClass.getDeclaredFields()) {
            try {

                if (isProperty(ctField)) {

                    // Property name
                    String propertyName = ctField.getName().substring(0, 1).toUpperCase() + ctField.getName().substring(1);
                    String getter = "get" + propertyName;
                    String setter = "set" + propertyName;

                    try {
                        CtMethod ctMethod = ctClass.getDeclaredMethod(getter);
                        if (ctMethod.getParameterTypes().length > 0 || Modifier.isStatic(ctMethod.getModifiers())) {
                            throw new NotFoundException("it's not a getter !");
                        }
                    } catch (NotFoundException noGetter) {

                        // Getter creation
                        String code = "public " + ctField.getType().getName() + " " + getter + "() { return this." + ctField.getName() + "; }";
                        CtMethod getMethod = CtMethod.make(code, ctClass);
                        ctClass.addMethod(getMethod);
                        createAnnotation(getAnnotations(getMethod), PlayPropertyAccessor.class);
                    }

                    if (!isFinal(ctField)) {
                        try {
                            CtMethod ctMethod = ctClass.getDeclaredMethod(setter);
                            if (ctMethod.getParameterTypes().length != 1 || !ctMethod.getParameterTypes()[0].equals(ctField.getType()) || Modifier.isStatic(ctMethod.getModifiers())) {
                                throw new NotFoundException("it's not a setter !");
                            }
                        } catch (NotFoundException noSetter) {
                            // Setter creation
                            CtMethod setMethod = CtMethod.make("public void " + setter + "(" + ctField.getType().getName() + " value) { this." + ctField.getName() + " = value; }", ctClass);
                            ctClass.addMethod(setMethod);
                            createAnnotation(getAnnotations(setMethod), PlayPropertyAccessor.class);
                        }
                    }

                }

            } catch (Exception e) {
                String message = "Failed to generate default accessor for " + ctClass.getName() + "." + ctField.getName();
                Logger.error(e, message);
                throw new UnexpectedException(message, e);
            }
        }
    }

    private void interceptAllFieldsAccess(final CtClass ctClass) throws CannotCompileException {
        for (final CtBehavior ctMethod : ctClass.getDeclaredBehaviors()) {
            ctMethod.instrument(new ExprEditor() {

                @Override
                public void edit(FieldAccess fieldAccess) throws CannotCompileException {
                    try {

                        // Check access to property ?
                        if (isProperty(fieldAccess.getField())) {

                            // TODO : Check if it is a application class field (fieldAccess.getClassName())

                            // Getter or setter ?
                            String propertyName = null;

                            if (fieldAccess.getField().getDeclaringClass().equals(ctMethod.getDeclaringClass())
                                    || ctMethod.getDeclaringClass().subclassOf(fieldAccess.getField().getDeclaringClass())) {
                                if ((ctMethod.getName().startsWith("get") || (!isFinal(fieldAccess.getField()) && ctMethod.getName().startsWith("set"))) && ctMethod.getName().length() > 3) {
                                    propertyName = ctMethod.getName().substring(3);
                                    propertyName = propertyName.substring(0, 1).toLowerCase() + propertyName.substring(1);
                                }
                            }

                            // To intercept getter of its own property
                            if (propertyName == null || !propertyName.equals(fieldAccess.getFieldName())) {

                                String invocationPoint = ctClass.getName() + "." + ctMethod.getName() + ", line " + fieldAccess.getLineNumber();

                                if (fieldAccess.isReader()) {

                                    // Rewrite read access to the property
                                    fieldAccess.replace("$_ = ($r)play.classloading.enhancers.PropertiesEnhancer.FieldAccessor.invokeReadProperty($0, \"" + fieldAccess.getFieldName() + "\", \"" + fieldAccess.getClassName() + "\", \"" + invocationPoint + "\");");

                                } else if (!isFinal(fieldAccess.getField()) && fieldAccess.isWriter()) {

                                    // Rewrite write access to the property
                                    fieldAccess.replace("play.classloading.enhancers.PropertiesEnhancer.FieldAccessor.invokeWriteProperty($0, \"" + fieldAccess.getFieldName() + "\", " + fieldAccess.getField().getType().getName() + ".class, $1, \"" + fieldAccess.getClassName() + "\", \"" + invocationPoint + "\");");


                                }
                            }
                        }

                    } catch (Exception e) {
                        String message = "Failed to modify access to " + ctClass.getName() + "." + ctMethod.getName();
                        throw new UnexpectedException(message, e);
                    }
                }
            });
        }
    }

    /**
     * Is this field a valid javabean property ?
     */
    boolean isProperty(CtField ctField) {
        if (ctField.getName().equals(ctField.getName().toUpperCase()) || ctField.getName().substring(0, 1).equals(ctField.getName().substring(0, 1).toUpperCase())) {
            return false;
        }
        return Modifier.isPublic(ctField.getModifiers())
                && !Modifier.isStatic(ctField.getModifiers())
                // protected classes will be considered public by this call
                && Modifier.isPublic(ctField.getDeclaringClass().getModifiers());
    }

    /**
     * Is this field final ?
     */
    boolean isFinal(CtField ctField) {
        return Modifier.isFinal(ctField.getModifiers());
    }

    /**
     * Runtime part.
     */
    public static class FieldAccessor {

        public static Object invokeReadProperty(Object o, String property, String targetType, String invocationPoint) throws Throwable {
            if (o == null) {
                throw new NullPointerException("Try to read " + property + " on null object " + targetType + " (" + invocationPoint + ")");
            }
            if (o.getClass().getClassLoader() == null || !o.getClass().getClassLoader().equals(Play.classloader)) {
                return o.getClass().getField(property).get(o);
            }
            String getter = "get" + property.substring(0, 1).toUpperCase() + property.substring(1);
            try {
                Method getterMethod = o.getClass().getMethod(getter);
                Object result = getterMethod.invoke(o);
                return result;
            } catch (NoSuchMethodException e) {
                return o.getClass().getField(property).get(o);
            } catch (InvocationTargetException e) {
                throw e.getCause();
            }
        }

        public static void invokeWriteProperty(Object o, String property, Class<?> valueType, boolean value, String targetType, String invocationPoint) throws Throwable {
            invokeWriteProperty(o, property, valueType, Boolean.valueOf(value), targetType, invocationPoint);
        }

        public static void invokeWriteProperty(Object o, String property, Class<?> valueType, byte value, String targetType, String invocationPoint) throws Throwable {
            invokeWriteProperty(o, property, valueType, Byte.valueOf(value), targetType, invocationPoint);
        }

        public static void invokeWriteProperty(Object o, String property, Class<?> valueType, char value, String targetType, String invocationPoint) throws Throwable {
            invokeWriteProperty(o, property, valueType, Character.valueOf(value), targetType, invocationPoint);
        }

        public static void invokeWriteProperty(Object o, String property, Class<?> valueType, double value, String targetType, String invocationPoint) throws Throwable {
            invokeWriteProperty(o, property, valueType, Double.valueOf(value), targetType, invocationPoint);
        }

        public static void invokeWriteProperty(Object o, String property, Class<?> valueType, float value, String targetType, String invocationPoint) throws Throwable {
            invokeWriteProperty(o, property, valueType, Float.valueOf(value), targetType, invocationPoint);
        }

        public static void invokeWriteProperty(Object o, String property, Class<?> valueType, int value, String targetType, String invocationPoint) throws Throwable {
            invokeWriteProperty(o, property, valueType, Integer.valueOf(value), targetType, invocationPoint);
        }

        public static void invokeWriteProperty(Object o, String property, Class<?> valueType, long value, String targetType, String invocationPoint) throws Throwable {
            invokeWriteProperty(o, property, valueType, Long.valueOf(value), targetType, invocationPoint);
        }

        public static void invokeWriteProperty(Object o, String property, Class<?> valueType, short value, String targetType, String invocationPoint) throws Throwable {
            invokeWriteProperty(o, property, valueType, Short.valueOf(value), targetType, invocationPoint);
        }

        public static void invokeWriteProperty(Object o, String property, Class<?> valueType, Object value, String targetType, String invocationPoint) throws Throwable {
            if (o == null) {
                throw new NullPointerException("Attempting to write a property " + property + " on a null object of type " + targetType + " (" + invocationPoint + ")");
            }
            String setter = "set" + property.substring(0, 1).toUpperCase() + property.substring(1);
            try {
                Method setterMethod = o.getClass().getMethod(setter, valueType);
                setterMethod.invoke(o, value);
            } catch (NoSuchMethodException e) {
                o.getClass().getField(property).set(o, value);
            } catch (InvocationTargetException e) {
                throw e.getCause();
            }
        }
    }

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface PlayPropertyAccessor {
    }
}
